var BDD = (function () {
    'use strict';

    var redStyle = BROWSER.supportsStyledConsoleLogging() ? 'color: red; font-weight: normal' : ' ',
        greenStyle = BROWSER.supportsStyledConsoleLogging() ? 'color: green; font-weight: normal' : ' ',
        stylePrefix = BROWSER.supportsStyledConsoleLogging() ? '%c' : '',
        failPrefix = stylePrefix + '\u2717 ',
        okPrefix = stylePrefix + '\u2713 ',
        currentTrace = { down: [], ran: 0, bad: 0 };


    function fail(msg) {
        function prepareStackTrace(err, csf, lines) {
            lines.push('at' + (csf.fn ? ' ' : '') + csf.fn  + ' ' + csf.file + ':' + csf.line);

            return !/bdd\.js$/.exec(csf.file);
        }

        currentTrace.failed = currentTrace.failed || [];
        currentTrace.failed.push({
            name : Array.prototype.slice.call(arguments).join(' '),
            trace : BROWSER.stackTrace(prepareStackTrace)
        });
    }

    function describe(desc, callback) {
        var d, start;

        d = currentTrace;
        currentTrace = {name: desc + '...', down: [], ran: 0, bad: 0 };
        d.down.push(currentTrace);

        start = window.performance.now();
        try {
            callback();
        } catch (err) {
            fail('unexpected exception:', err);
        }
        currentTrace.time = window.performance.now() - start;

        d.ran += currentTrace.ran;
        d.bad += currentTrace.bad;
        if (currentTrace.failed) {
            d.failed = true;
        }
        currentTrace = d;
    }

    function it(desc, callback) {
        var d, start;

        d = currentTrace;
        currentTrace = {name: desc};
        d.down.push(currentTrace);

        start = window.performance.now();
        try {
            callback();
        } catch (err) {
            fail('unexpected exception:', err);
        }
        currentTrace.time = window.performance.now() - start;

        d.ran += 1;
        if (currentTrace.failed) {
            d.failed = true;
            d.bad += 1;
        }
        currentTrace = d;
    }

    function eql(a, b, cmp, tolerance) {
        if (a === b) {
            return true;
        }

        if (typeof a === 'number' && typeof b === 'number') {
            return a - tolerance <= b && b <= a + tolerance;
        }

        if (typeof a === 'object' && typeof b === 'object') {
            var aKeys = Object.keys(a), bKeys = Object.keys(b), i, key;

            if (aKeys.length !== bKeys.length) {
                return false;
            }

            aKeys.sort();
            bKeys.sort();

            for (i = 0; i < aKeys.length; i += 1) {
                if (aKeys[i] !== bKeys[i]) {
                    return false;
                }
            }

            for (i = 0; i < aKeys.length; i += 1) {
                key = aKeys[i];
                if (!cmp(a[key], b[key], cmp, tolerance)) {
                    return false;
                }
            }

            return true;
        }

        return false;
    }

    function Assertion(obj, what) {
        this.what = what || 'object';
        this.obj = obj;
        this.be = this.to = this;
        this.positive = true;

        this.not = Object.create(this);
        this.not.positive = false;
        this.not.not = this;

        this.cmp = eql;
        this.cmpFn = '';
        this.tolerance = 0;
    }

    function expect(obj, what) {
        return new Assertion(obj, what);
    }

    Assertion.prototype.equal = function (obj) {
        if (this.positive && !this.cmp(this.obj, obj, eql, this.tolerance)) {
            fail('expected', this.what, JSON.stringify(this.obj), 'to equal', JSON.stringify(obj), this.cmpFn);
        } else if (!this.positive && this.cmp(this.obj, obj, eql, this.tolerance)) {
            fail('expected', this.what, JSON.stringify(this.obj), 'to not equal', JSON.stringify(obj), this.cmpFn);
        }
    };

    Assertion.prototype.match = function (re) {
        if (re.constructor !== RegExp) {
            fail('expected to match, but argument ' + re + ' is not a RegExp');
        }

        if (this.positive && !re.exec(this.obj.toString())) {
            fail('expected', this.what, JSON.stringify(this.obj), 'to match', re);
        } else if (!this.positive && re.exec(this.obj.toString())) {
            fail('expected', this.what, JSON.stringify(this.obj), 'to not match', re);
        }
    };

    Assertion.prototype.haveProperty = function (prop) {
        if (this.obj[prop] === undefined) {
            fail('expected', this.what, 'to have a', '\'' + prop + '\'', 'property');
        }
    };

    Assertion.prototype.subset = function (set) {
        var i, j;
        for (i = 0; i < this.obj.length; i += 1) {
            for (j = 0; j < set.length; j += 1) {
                if (this.cmp(this.obj[i], set[j], eql, this.tolerance)) {
                    break;
                }
            }
            if (j === set.length) {
                if (this.positive) {
                    fail('expected', JSON.stringify(this.obj), 'to be a subset of', JSON.stringify(set), this.cmpFn);
                }
                return;
            }
        }
        if (!this.positive) {
            fail('expected', JSON.stringify(this.obj), 'to not be a subset of', JSON.stringify(set));
        }
    };

    Assertion.prototype.a = Assertion.prototype.an = function (expectedType) {
        if (typeof expectedType === 'function') {
            if (!(this.obj instanceof expectedType)) {
                fail(this.what, 'is not an instance of', expectedType.name);
            }
        } else if (typeof this.obj !== expectedType.toString()) {
            fail(this.what, 'has type', typeof this.obj, 'not', expectedType);
        }
    };

    Assertion.prototype.throw = function (exp) {
        expect(this.obj).to.be.a('function');
        try {
            if (typeof this.obj === 'function') {
                this.obj.apply(this.self, this.arguments);
                if (this.positive) {
                    fail('expected function ' + this.obj.name + '() to throw an exception');
                }
            }
        } catch (err) {
            if (!this.positive) {
                fail('expected function ' + this.obj.name + '() to not throw an exception');
                fail('exception message "' + err.message + '"');
            } else {
                expect(err, 'thrown exception').to.be.an(Error);
                if (exp && exp.constructor !== RegExp) {
                    expect(err.message, 'exception message').to.equal(exp);
                } else if (exp) {
                    expect(err.message, 'exception message').to.match(exp);
                }
            }
        }
    };

    Assertion.prototype.with = function () {
        this.arguments = Array.prototype.slice.call(arguments);
        this.self = this.arguments.shift();
        return this;
    };

    Assertion.prototype.withCmp = function (cmp) {
        this.cmp = cmp;
        this.cmpFn = 'with comparisson function ' + cmp.name;
        return this;
    };

    Assertion.prototype.within = function (tolerance) {
        this.tolerance = tolerance;
        return this;
    };

    function logExecutionFailures(c) {
        var f, j;

        if (!c.failed) {
            return;
        }

        f = c.failed;
        if (f.length !== undefined) {
            for (j = 0; j < f.length; j += 1) {
                console.groupCollapsed(failPrefix + f[j].name, redStyle);
                console.log(f[j].trace);
                console.groupEnd();
            }
        }
    }

    function logExecutionTrace(trace) {
        function descOf(g) {
            var d = '';
            d += g.time.toFixed(2) + 'ms';
            d += '\t';
            d += g.name;
            if (g.ran) {
                d += '  (' + (g.ran - g.bad) + ' of ' + g.ran + ' tests successful)';
            }

            return d;
        }

        var i, c;
        trace = trace || currentTrace;

        if (trace.down === undefined) {
            return;
        }

        for (i = 0; i < trace.down.length; i += 1) {
            c = trace.down[i];

            if (c.down !== undefined || c.failed) {
                if (c.failed) {
                    console.group(failPrefix + descOf(c), redStyle);
                } else {
                    console.groupCollapsed(okPrefix + descOf(c), greenStyle);
                }
                logExecutionTrace(c);
                logExecutionFailures(c);
                console.groupEnd();
            } else {
                console.log(okPrefix + descOf(c), greenStyle);
            }
        }
    }

    return {
        describe: describe,
        it: it,
        expect: expect,
        fail: fail,
        logExecutionTrace: logExecutionTrace
    };
}());
